![[為你自己寫 Vue Component] AtomicFormField](https://ithelp.ithome.com.tw/upload/images/20240925/20120484SHcnvXuJwX.png)
在一個專案當中,標單數入元件通常會有統一的外觀風格,讓整個系統看起來更一致、整齊。<AtomicFormField> 是用來渲染表單欄位的元件。它是 <input>、<select> 等元素的包裝器(Wrapper),提供表單欄位一致的外觀和使用體驗。
<AtomicFormField> 作為各種元件的包裝器,在後面將實作的 <AtomicTextField>、<AtomicTextarea> 與 <AtomicSelect> 等元件內部都會直接使用 <AtomicFormField>。這部分與多數的 UI Library 不太一樣。在 UI Library 的設計上,大多會盡可能將能拆開的元件拆開,這樣使用者就有選擇單獨使用或組合成新元件的空間。因此,如果目標是設計開源的 UI Library,會建議 FormField 歸 FormField,TextField 歸 TextField 更好一些。


<input>、<textarea>、<select>。在開始實作前,我們先研究各個 UI Library 的 Field 相關元件是如何設計的。
Element Plus
<template>
  <ElForm :model="form" label-width="auto" style="max-width: 600px">
    <ElFormItem label="Activity name">
      <ElInput v-model="form.name" />
    </ElFormItem>
    <ElFormItem label="Activity zone">
      <ElSelect v-model="form.region" placeholder="please select your zone">
        <ElOption label="Zone one" value="shanghai" />
        <ElOption label="Zone two" value="beijing" />
      </ElSelect>
    </ElFormItem>
  </ElForm>
</template>
在 Element Plus 中最接近的元件是 <ElFormItem>。<ElFormItem> 與 <ElForm> 整合後功能包山包海,甚至與表單欄位驗證結合在一起。
撇除各式各樣的功能,與 UI 相關的設定有:可以透過 label 設定標籤內容,透過 label-width 設定標籤寬度,透過 label-position 設定標籤位置。也可以透過 error 設定錯誤訊息及 required 設定欄位為必填。
Nuxt UI
<template>
  <UFormGroup label="Email" required>
    <UInput placeholder="you@example.com" icon="i-heroicons-envelope" />
  </UFormGroup>
</template>
Nuxt UI 的 <UFormGroup> 可以透過 label 設定欄位標籤內容,透過 required 設定欄位為必填。還可以透過 description 與 help 分別設定欄位的說明文字與提示文字,透過 error 標記欄位是否有錯誤。
Element Plus 的 <ElFormItem> 與 Nuxt UI 的 <UFormGroup> 都是將標籤、控制區塊、說明文字、錯誤訊息整合在一起的元件。
綜合以上並結合自身經驗,我們統整出 <AtomicFormField> 的功能:
label 設定欄位標籤內容。labelPlacement 設定欄位標籤位置。labelWidth 設定欄位標籤寬度。hideLabel 設定是否隱藏標籤。message 設定欄位提示訊息。error 表示欄位是否有錯誤,如果有錯誤 message 則表示錯誤訊息。required 設定是否顯示欄位必填標記。disabled 設定欄位是否禁用。readonly 設定欄位是否唯讀。使用結構如下:
<template>
  <AtomicFormField
    label="姓名"
    labelPlacement="top"
    labelWidth="fit-content"
  >
    <input />
  </AtomicFormField>
</template>
另外在 Element Plus 中,<ElFormItem> 還接受了欄位驗證功能設定的功能,這個部分在我們的 <AtomicFormField> 中並不會實作。
首先,我們將需求中提到的功能整理成 props 的介面,我們會需要下列屬性:
| 名稱 | 型別 | 預設值 | 說明 | 
|---|---|---|---|
| label | string,undefined | undefined | 欄位標籤 | 
| labelPlacement | top,left | left | 欄位標籤位置 | 
| labelWidth | string,number | fit-content | 欄位標籤寬度 | 
| hideLabel | boolean | false | 是否隱藏欄位標籤 | 
| message | string,undefined | undefined | 欄位提示訊息 | 
| error | boolean | false | 欄位是否有錯誤 | 
| required | boolean | false | 欄位是否必填 | 
| disabled | boolean | false | 欄位是否禁用 | 
| readonly | boolean | false | 欄位是否唯讀 | 
export interface AtomicFormFieldProps {
  label?: string;
  labelPlacement?: 'top' | 'left';
  labelWidth?: string | number;
  hideLabel?: boolean;
  message?: string;
  error?: boolean;
  required?: boolean;
  disabled?: boolean;
  readonly?: boolean;
}
const props = withDefaults(defineProps<AtomicFormFieldProps>(), {
  label: undefined,
  labelPlacement: 'left',
  labelWidth: 'fit-content',
  message: undefined,
});
<AtomicFormField> 的元件定位是一個包裝元件,他接收了各種設定,並將這些設定套用到內部的結構上。而未來使用的元件同樣需要接收這些設定,為了讓其他元件元件可以把 <AtomicFormField> 需要的 props 挑出來並且傳下去,我們可以實作一個 useFormFieldProps 的 Composable API,他會將 props 物件中 <AtomicFormField> 需要的 props 挑出來。
function pick<T extends Record<string, any>, K extends keyof T>(
  obj: T,
  keys: K[]
) {
  return keys.reduce((acc, key) => {
    if (obj[key] !== undefined) acc[key] = obj[key];
    return acc;
  }, {} as Pick<T, K>);
}
export function useFormFieldProps(
  props: MaybeRefOrGetter<AtomicFormFieldProps>
) {
  return computed<AtomicFormFieldProps>(() =>
    pick(toValue(props), [
      'label',
      'labelPlacement',
      'labelWidth',
      'hideLabel',
      'message',
      'error',
      'required',
      'disabled',
      'readonly',
    ])
  );
}
這樣在其他元件當中,只要這樣使用,就可以把 <AtomicFormField> 需要的 props 挑出來並且傳遞下去。
const fieldProps = useFormFieldProps(() => props);
<template>
  <AtomicFormField v-bind="fieldProps">
    <!-- 略 -->
  </AtomicFormField>
</template>
<AtomicFormField> 雖然會讓很多元件共同使用,但它的定位是包裝元件而非基礎元件,因此我們不會承攬其他元件的任何功能,只會專注在樣式設計上。因此我們盡可能只專注地處理好 HTML 跟 CSS 就好。
<template>
  <div
    class="atomic-form-field"
    :class="{
      'atomic-form-field--error': error,
      'atomic-form-field--readonly': readonly,
      'atomic-form-field--disabled': disabled,
      'atomic-form-field--required': !hideLabel && required,
      'atomic-form-field--hide-label': hideLabel,
      [`atomic-form-field--label-${labelPlacement}`]: !!labelPlacement,
    }"
    :style="!hideLabel
      ? {
        '--field-label-width': toUnit(labelWidth)
      }
      : undefined
    "
  >
    <div class="atomic-form-field__container">
      <div class="atomic-form-field__label">
        <label class="atomic-form-field__label-content">
          {{ label }}
        </label>
      </div>
      <div class="atomic-form-field__content">
        <div class="atomic-form-field__control">
          <slot name="default" />
        </div>
        <div class="atomic-form-field__message">
          {{ message }}
        </div>
      </div>
    </div>
  </div>
</template>
結構上非常單純,我們保留了 default slot 的位置給其他元件放入各自的 UI,並且將 label 與 message 顯示在適當的結構。
為了讓 label 與 message 的使用更有彈性,我們可以讓使用者透過 slot 的方式放入 label 與 message。
label
<div class="atomic-form-field__label">
  <label class="atomic-form-field__label-content">
    <slot
      :label="label"
      name="label"
    >
      <span>
        {{ label }}
      </span>
    </slot>
  </label>
</div>
message
<div class="atomic-form-field__message">
  <slot
    :error="error"
    :message="message"
    name="message"
  >
    {{ message }}
  </slot>
</div>
然而 label 與 message 並不總是存在,我們可以讓它們在不存在時不顯示,我們可以考慮使用 v-if 或是 v-show 來處理。
在選用哪一種方式時,我自己會依據:如果是在網站操作過程中容易變動的,會使用 v-show,如果是相對穩定的存在或不存在,則會選用 v-if。
在這裡 label 我會使用 v-if,message 選用 v-show。
label
<div
  v-if="label || $slots.label"
  class="atomic-form-field__label"
>
  <label class="atomic-form-field__label-content">
    <slot
      :label="label"
      name="label"
    >
      <span>
        {{ label }}
      </span>
    </slot>
  </label>
</div>
message
<div
  v-show="message || $slots.message"
  :id="`${id}-message`"
  class="atomic-form-field__message"
>
  <slot
    :error="error"
    :message="message"
    name="message"
  >
    {{ message }}
  </slot>
</div>
這樣一來 HTML 結構大致完成。
不過我們在前面有一個 hideLabel 的設定用來隱藏 Label 結構,但我們並沒有使用 v-if 來處理,這是因為我們希望在 hideLabel 設定為 true 時,Label 結構仍然存在,只是不顯示而已。
更正確地說是:視覺上不存在。
@mixin sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border-width: 0;
}
.atomic-form-field {
  &--hide-label &__label {
    @include sr-only;
  }
}
在 <AtomicBreadcrumb> 中我們有使用到 sr-only 這個 mixin,這個 mixin 是用來隱藏元素但保留在 DOM 中,這樣可以讓輔助技術(例如螢幕閱讀器)可以正確地讀取到這個元素。
還有 labelPlacement 的設定,我們可以透過 CSS 變數來設定 Label 的位置。在不動到架構的情況下,我們可以使用 CSS 的 Flex 來調整 Label 的位置。
.atomic-form-field {
  &__container {
    display: flex;
    width: 100%;
  }
  &--label-left &__container {
    align-items: stretch;
    column-gap: 8px;
  }
  &--label-top &__container {
    flex-direction: column;
    row-gap: 6px;
  }
}
隨著 labelPlacement 的不同我們的 Label 區塊也有一些細節需要調整,Label 在上方的畫面會比較單純,但如果 Label 在左側時,我們需要盡可能讓 Label 與 Control 區塊對齊,這樣畫面會比較整齊。

.atomic-form-field {
  --field-height: 38px;
  &--label-left &__label {
    width: var(--field-label-width);
    line-height: var(--field-height);
  }
}
在這裡我們可以使用 line-height: var(--field-height) 來讓 Label 垂直置中,這樣如果旁邊的 Control 區塊高度剛好也等於 --field-height,整個欄位就會水平置中。而就算遇到像是 <AtomicTextarea> 這種高度不固定的元件,我們也可以讓所有的 Label 區塊有一致的高度。
這裡的高度使用 CSS 變數是為了讓未來使用 <AtomicFormField> 的元件內部可以透過這個變數來取得統一的高度。如果遇到 UI 需要統一調整高度時,我們只要在 <AtomicFormField> 裡面調整即可。
在網頁切版時,我們要讓 <label> 與 <input> 有對應關係,這樣輔助技術才能清楚地辨識每個 <label> 分別對應到的 <input>、<textarea> 與 <select> 是什麼。
像這樣,輔助技術或是搜尋引擎根本不會知道這個 <label> 是對應到哪個 <input>。
<label>姓名</label>
<input type="text" />
<label>Email</label>
<input type="text" />
加上 for 與 id 屬性,我們就可以讓 <label> 與 <input> 有對應關係。
<label for="name">姓名</label>
<input id="name" type="text" />
<label for="email">Email</label>
<input id="email" type="text" />
除此之外,加上 for 與 id 屬性後,當我們點擊 <label> 時,瀏覽器會幫我們自動將焦點轉移到對應的 <input> 上。

在這裡 <AtomicFormField> 儘管沒有包含任何表單控制的元素,但我們還是可以將每個 Field 的 id 準備好,並從 default slot 傳出去,這樣我們之後在實作 <AtomicTextField>、<AtomicTextarea> 與 <AtomicSelect> 時就可以直接使用。
const attrs = useAttrs();
const _id = `field-${Math.round(Math.random() * 1e5)}`;
const id = computed(() => (attrs.id as string) || _id);
<!-- Label -->
<label :for="id">
  <slot
    :label="label"
    name="label"
  >
    {{ label }}
  </slot>
</label>
<!-- Default Slot -->
<slot
  :id="id"
  name="default"
/>
在 <AtomicFormField> 中我們可以透過 aria-describedby 來指定 message 的 id,這樣螢幕閱讀器就可以將 message 的內容讀出來。
這裡我們一樣透過 default slot 傳遞 message 的 id。
<!-- Message -->
<div
  v-show="message || $slots.message"
  :id="`${id}-message`"
  class="atomic-form-field__message"
>
  <slot
    :error="error"
    :message="message"
    :id="`${id}-message`"
    name="message"
  >
    {{ message }}
  </slot>
</div>
<!-- Default Slot -->
<slot
  :id="id"
  :describedby="`${id}-message`"
  name="default"
/>
<AtomicFormField> 是用來包裝表單控制元件的元件,在這個元件的實作當中我們專注在模板與 CSS 的設計上,藉此機會也分享了我對於 v-if 與 v-show 選用的參考依據。
儘管我不將 <AtomicFormField> 定位於基礎元件,但因為這個元件的目的就是要讓其他元件能夠共用 UI,因此我們在這裡設計了一些 CSS 變數來讓其他元件可以使用,這樣我們就可以在未來的開發中更容易地調整 UI。
最後在無障礙的部分,我們讓 <label> 與 Control 區塊建立對應關係,這樣不但可以讓使用輔助技術的人更容易使用,也讓我們自己在操作時更加方便。
<AtomicFormField> 原始碼:AtomicFormField.vue